現在我們碰到一個在進行設計與測試時所遇到的一個常見問題:到底該不該測試 私有(private)
和 內部(internal)
成員?這不只是技術問題,更是設計思維的問題。
在實際專案中,我們常常遇到複雜的私有邏輯和組件內部的 internal 類別,要如何在不破壞封裝性的前提下進行測試?今天我們來討論這個議題的各種解法與權衡考量。
實際開發時,我們的程式碼不只有公開方法。複雜的業務邏輯往往藏在私有方法裡,而組件內部的協作則透過 internal 成員進行。這些非公開的程式碼同樣需要品質保證,但直接測試它們會遇到技術和設計上的挑戰。
有這麼一個原則:好的設計自然就有好的可測試性。
如果你發現自己經常需要測試私有方法,大部分可能是設計出了問題。
// 有問題的設計:複雜的私有邏輯
public class OrderProcessor
{
public OrderResult ProcessOrder(Order order)
{
var validationResult = ValidateOrder(order);
if (!validationResult.IsValid)
{
return OrderResult.Failed(validationResult.Errors);
}
var discountAmount = CalculateDiscount(order);
var tax = CalculateTax(order, discountAmount);
var total = CalculateTotal(order, discountAmount, tax);
return OrderResult.Success(total);
}
// 複雜的私有方法,很想測試它
private decimal CalculateDiscount(Order order)
{
// 20 行複雜的折扣計算邏輯
// 各種會員等級、促銷活動、季節性折扣...
}
private decimal CalculateTax(Order order, decimal discountAmount)
{
// 15 行複雜的稅率計算
// 根據地區、商品類型、企業用戶...
}
}
// 改進的設計:將複雜邏輯提取為獨立的服務
public class OrderProcessor
{
private readonly IDiscountCalculator _discountCalculator;
private readonly ITaxCalculator _taxCalculator;
private readonly IOrderValidator _orderValidator;
public OrderProcessor(
IDiscountCalculator discountCalculator,
ITaxCalculator taxCalculator,
IOrderValidator orderValidator)
{
_discountCalculator = discountCalculator;
_taxCalculator = taxCalculator;
_orderValidator = orderValidator;
}
public OrderResult ProcessOrder(Order order)
{
var validationResult = _orderValidator.Validate(order);
if (!validationResult.IsValid)
{
return OrderResult.Failed(validationResult.Errors);
}
var discountAmount = _discountCalculator.Calculate(order);
var tax = _taxCalculator.Calculate(order, discountAmount);
var total = order.Amount - discountAmount + tax;
return OrderResult.Success(total);
}
}
// 現在每個計算器都可以獨立測試
public class DiscountCalculator : IDiscountCalculator
{
public decimal Calculate(Order order)
{
// 複雜的折扣計算邏輯
// 但現在是公開方法,容易測試
}
}
這種設計的好處:
這種設計的好處讓我們能夠專注測試每個組件的公開行為,而不是糾結於內部實作細節。
我們只會對類別裡的公開方法做測試,不需要刻意地去對私有方法做測試。
私有方法必定會有公開方法去使用,所以有對公開方法做了測試就會測試到私有方法。
有時候我們需要測試 internal 成員,特別是在建立類別庫或框架時。.NET 提供了幾種讓測試專案能夠存取 internal 成員的方法。
我們來建立一個實際的例子來展示不同的技術。首先安裝必要的套件:
dotnet add package NSubstitute
dotnet add package AwesomeAssertions
主要專案中的 Internal 類別:
// PriceCalculator.cs
namespace Day09.Core;
/// <summary>
/// class PriceCalculator - 價格計算器(僅供內部使用)
/// </summary>
internal class PriceCalculator
{
/// <summary>
/// 計算商品等級
/// </summary>
/// <param name="price">商品價格</param>
/// <returns>商品等級</returns>
internal string CalculatePriceLevel(decimal price)
{
return price switch
{
>= 10000 => "豪華級",
>= 5000 => "高級",
>= 1000 => "中級",
> 0 => "經濟級",
_ => "無效價格"
};
}
/// <summary>
/// 計算折扣後價格
/// </summary>
/// <param name="originalPrice">原價</param>
/// <param name="discountRate">折扣率 (0-1之間)</param>
/// <returns>折扣後價格</returns>
internal decimal CalculateDiscountedPrice(decimal originalPrice, decimal discountRate)
{
if (discountRate is < 0 or > 1)
{
throw new ArgumentException("折扣率必須在0到1之間");
}
return originalPrice * (1 - discountRate);
}
}
最直接的方法是在組件中加入 InternalsVisibleTo
屬性:
// AssemblyInfo.cs 或任何類別檔案中
using System.Runtime.CompilerServices;
[assembly: InternalsVisibleTo("Day09.Tests")]
測試程式碼:
// PriceCalculatorTests.cs
namespace Day09.Tests;
/// <summary>
/// PriceCalculator 測試類別(測試 Internal 成員)
/// </summary>
public class PriceCalculatorTests
{
[Theory]
[InlineData(15000, "豪華級")]
[InlineData(8000, "高級")]
[InlineData(3000, "中級")]
[InlineData(500, "經濟級")]
[InlineData(0, "無效價格")]
public void CalculatePriceLevel_不同價格_應回傳正確等級(decimal price, string expected)
{
// Arrange
var calculator = new PriceCalculator();
// Act
var actual = calculator.CalculatePriceLevel(price);
// Assert
actual.Should().Be(expected);
}
[Theory]
[InlineData(1000, 0.1, 900)]
[InlineData(2000, 0.2, 1600)]
[InlineData(500, 0.05, 475)]
public void CalculateDiscountedPrice_正常折扣_應計算正確價格(
decimal originalPrice, decimal discountRate, decimal expected)
{
// Arrange
var calculator = new PriceCalculator();
// Act
var actual = calculator.CalculateDiscountedPrice(originalPrice, discountRate);
// Assert
actual.Should().Be(expected);
}
[Theory]
[InlineData(-0.1)]
[InlineData(1.1)]
public void CalculateDiscountedPrice_無效折扣率_應拋出例外(decimal invalidDiscountRate)
{
// Arrange
var calculator = new PriceCalculator();
// Act & Assert
var action = () => calculator.CalculateDiscountedPrice(1000, invalidDiscountRate);
action.Should().Throw<ArgumentException>()
.WithMessage("折扣率必須在0到1之間");
}
}
<!-- Day09.Core.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Day09.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
</Project>
對於複雜的專案,推薦使用這個 NuGet 套件:
相關連結:
<!-- Day09.Core.csproj -->
<ItemGroup>
<PackageReference Include="Meziantou.MSBuild.InternalsVisibleTo" Version="1.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="$(AssemblyName).IntegrationTests" />
</ItemGroup>
評估面向 | 風險程度 | 說明 |
---|---|---|
封裝性破壞 | 中等 | 增加了測試對內部實作的依賴 |
重構阻力 | 高 | 改變 internal 成員會影響測試 |
維護成本 | 中等 | 需要同步維護生產代碼和測試代碼 |
設計品質 | 低 | 如果過度使用,可能表示設計有問題 |
何時適合測試 Internal 成員:
雖然我們強調設計優先,但在實務中仍有需要測試私有方法的情況:
需要測試私有方法的常見情境:
以下是實作技術:
// 包含私有方法的類別
using Day09.Core.Models;
namespace Day09.Core;
/// <summary>
/// class PaymentProcessor - 付款處理器
/// </summary>
public class PaymentProcessor
{
/// <summary>
/// 處理付款
/// </summary>
/// <param name="request">付款請求</param>
/// <returns>付款結果</returns>
public PaymentResult ProcessPayment(PaymentRequest request)
{
if (!ValidateRequest(request))
{
return PaymentResult.Failed("Invalid request");
}
var fee = CalculateFee(request.Amount, request.PaymentMethod);
var total = request.Amount + fee;
return PaymentResult.Success(total);
}
/// <summary>
/// 驗證請求(私有方法,包含複雜邏輯)
/// </summary>
/// <param name="request">付款請求</param>
/// <returns>是否有效</returns>
private bool ValidateRequest(PaymentRequest request)
{
return request is { Amount: > 0 };
}
/// <summary>
/// 計算手續費(私有方法,包含複雜邏輯)
/// </summary>
/// <param name="amount">金額</param>
/// <param name="method">付款方式</param>
/// <returns>手續費</returns>
private decimal CalculateFee(decimal amount, PaymentMethod method)
{
return method switch
{
PaymentMethod.CreditCard => amount * 0.03m,
PaymentMethod.DebitCard => amount * 0.01m,
PaymentMethod.BankTransfer => Math.Max(amount * 0.005m, 10m),
_ => 0m
};
}
/// <summary>
/// 檢查是否為工作日(靜態私有方法)
/// </summary>
/// <param name="date">日期</param>
/// <returns>是否為工作日</returns>
private static bool IsBusinessDay(DateTime date)
{
return date.DayOfWeek != DayOfWeek.Saturday &&
date.DayOfWeek != DayOfWeek.Sunday;
}
}
測試私有實例方法:
[Theory]
[InlineData(1000, PaymentMethod.CreditCard, 30)]
[InlineData(1000, PaymentMethod.DebitCard, 10)]
[InlineData(1000, PaymentMethod.BankTransfer, 10)]
[InlineData(100, PaymentMethod.BankTransfer, 10)] // 最低手續費
[InlineData(5000, PaymentMethod.BankTransfer, 25)] // 5000 * 0.005 = 25
public void CalculateFee_不同付款方式_應計算正確手續費(
decimal amount, PaymentMethod method, decimal expected)
{
// Arrange
var processor = new PaymentProcessor();
var type = typeof(PaymentProcessor);
var methodInfo = type.GetMethod("CalculateFee", BindingFlags.NonPublic | BindingFlags.Instance);
// Act
var actual = (decimal)(methodInfo!.Invoke(processor, [amount, method]) ?? 0m);
// Assert
actual.Should().Be(expected);
}
測試靜態私有方法:
[Theory]
[InlineData("2024-03-15", true)] // 星期五
[InlineData("2024-03-16", false)] // 星期六
[InlineData("2024-03-17", false)] // 星期日
[InlineData("2024-03-18", true)] // 星期一
public void IsBusinessDay_不同日期_應回傳正確結果(string dateString, bool expected)
{
// Arrange
var date = DateTime.Parse(dateString);
var type = typeof(PaymentProcessor);
var methodInfo = type.GetMethod("IsBusinessDay", BindingFlags.NonPublic | BindingFlags.Static);
// Act
var actual = (bool)(methodInfo!.Invoke(null, [date]) ?? false);
// Assert
actual.Should().Be(expected);
}
為了簡化反射測試,可以建立輔助方法:
// Helpers/ReflectionTestHelper.cs
using System;
namespace Day09.Tests.Helpers;
/// <summary>
/// 反射測試輔助類別
/// </summary>
public static class ReflectionTestHelper
{
/// <summary>
/// 呼叫私有實例方法
/// </summary>
/// <typeparam name="T">回傳型別</typeparam>
/// <param name="instance">物件實例</param>
/// <param name="methodName">方法名稱</param>
/// <param name="parameters">參數</param>
/// <returns>方法執行結果</returns>
public static T InvokePrivateMethod<T>(object instance, string methodName, params object[] parameters)
{
var type = instance.GetType();
var method = type.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Instance);
if (method is null)
{
throw new ArgumentException($"Method '{methodName}' not found in type '{type.Name}'");
}
var result = method.Invoke(instance, parameters);
return result is null ? default! : (T)result;
}
/// <summary>
/// 呼叫私有靜態方法
/// </summary>
/// <typeparam name="T">回傳型別</typeparam>
/// <param name="type">型別</param>
/// <param name="methodName">方法名稱</param>
/// <param name="parameters">參數</param>
/// <returns>方法執行結果</returns>
public static T InvokePrivateStaticMethod<T>(Type type, string methodName, params object[] parameters)
{
var method = type.GetMethod(methodName, BindingFlags.NonPublic | BindingFlags.Static);
if (method is null)
{
throw new ArgumentException($"Static method '{methodName}' not found in type '{type.Name}'");
}
var result = method.Invoke(null, parameters);
return result is null ? default! : (T)result;
}
}
使用輔助方法簡化測試:
[Fact]
public void CalculateFee_使用輔助方法_應計算正確手續費()
{
// Arrange
var processor = new PaymentProcessor();
var amount = 1000m;
var method = PaymentMethod.CreditCard;
// Act
var actual = ReflectionTestHelper.InvokePrivateMethod<decimal>(
processor, "CalculateFee", amount, method);
// Assert
actual.Should().Be(30m);
}
[Fact]
public void IsBusinessDay_使用輔助方法_應正確判斷工作日()
{
// Arrange
var date = new DateTime(2024, 3, 15); // 星期五
// Act
var actual = ReflectionTestHelper.InvokePrivateStaticMethod<bool>(
typeof(PaymentProcessor), "IsBusinessDay", date);
// Assert
actual.Should().BeTrue();
}
何時考慮測試私有方法:
何時不要測試私有方法:
當私有方法包含複雜邏輯時,考慮使用策略模式:
// 原本的設計:難以測試的私有方法
public class PricingService
{
public decimal CalculatePrice(Product product, Customer customer)
{
var basePrice = product.BasePrice;
var discount = CalculateDiscount(customer, product); // 私有方法,邏輯複雜
var tax = CalculateTax(product, customer.Location); // 私有方法,邏輯複雜
return basePrice - discount + tax;
}
private decimal CalculateDiscount(Customer customer, Product product)
{
// 30 行複雜的折扣計算邏輯
// VIP 折扣、數量折扣、季節性折扣、促銷活動...
}
private decimal CalculateTax(Product product, Location location)
{
// 20 行複雜的稅收計算邏輯
// 不同地區、商品類型、企業客戶...
}
}
// 折扣計算策略介面
public interface IDiscountStrategy
{
decimal Calculate(Customer customer, Product product);
}
// 稅收計算策略介面
public interface ITaxStrategy
{
decimal Calculate(Product product, Location location);
}
// 折扣計算策略實作
public class StandardDiscountStrategy : IDiscountStrategy
{
public decimal Calculate(Customer customer, Product product)
{
// 標準折扣邏輯 - 現在是公開方法,容易測試
if (customer.IsVip)
{
return product.BasePrice * 0.1m;
}
return 0m;
}
}
// 稅收計算策略實作
public class TaiwanTaxStrategy : ITaxStrategy
{
public decimal Calculate(Product product, Location location)
{
// 台灣稅收計算邏輯 - 現在是公開方法,容易測試
return product.BasePrice * 0.05m;
}
}
// 改進後的定價服務
public class PricingService
{
private readonly IDiscountStrategy _discountStrategy;
private readonly ITaxStrategy _taxStrategy;
public PricingService(IDiscountStrategy discountStrategy, ITaxStrategy taxStrategy)
{
_discountStrategy = discountStrategy;
_taxStrategy = taxStrategy;
}
public decimal CalculatePrice(Product product, Customer customer)
{
var basePrice = product.BasePrice;
var discount = _discountStrategy.Calculate(customer, product);
var tax = _taxStrategy.Calculate(product, customer.Location);
return basePrice - discount + tax;
}
}
測試策略實作:
using Day09.Core.StrategyPattern;
namespace Day09.Tests.StrategyPattern;
/// <summary>
/// StandardDiscountStrategy 測試類別
/// </summary>
public class StandardDiscountStrategyTests
{
[Fact]
public void Calculate_VIP客戶_應給予折扣()
{
// Arrange
var strategy = new StandardDiscountStrategy();
var customer = new Customer { IsVip = true };
var product = new Product { BasePrice = 1000m };
// Act
var discount = strategy.Calculate(customer, product);
// Assert
discount.Should().Be(100m);
}
[Fact]
public void Calculate_一般客戶_應無折扣()
{
// Arrange
var strategy = new StandardDiscountStrategy();
var customer = new Customer { IsVip = false };
var product = new Product { BasePrice = 1000m };
// Act
var discount = strategy.Calculate(customer, product);
// Assert
discount.Should().Be(0m);
}
}
策略模式架構圖:
有時候我們只想模擬類別的部分行為:
// 需要部分模擬的類別
using Day09.Core.Models;
namespace Day09.Core;
/// <summary>
/// class DataProcessor - 需要部分模擬的資料處理器
/// </summary>
public class DataProcessor
{
/// <summary>
/// 處理資料
/// </summary>
/// <param name="data">要處理的資料</param>
/// <returns>處理結果</returns>
public virtual ProcessResult ProcessData(string data)
{
var validationResult = ValidateData(data);
if (!validationResult.IsValid)
{
return ProcessResult.Failed(validationResult.Errors);
}
var processedData = TransformData(data);
var result = SaveData(processedData);
return result;
}
/// <summary>
/// 驗證資料
/// </summary>
/// <param name="data">要驗證的資料</param>
/// <returns>驗證結果</returns>
protected virtual ValidationResult ValidateData(string data)
{
// 複雜的驗證邏輯
if (string.IsNullOrEmpty(data))
{
return new ValidationResult
{
IsValid = false,
Errors = ["Data cannot be null or empty"]
};
}
return new ValidationResult { IsValid = true };
}
/// <summary>
/// 轉換資料
/// </summary>
/// <param name="data">要轉換的資料</param>
/// <returns>轉換後的資料</returns>
protected virtual string TransformData(string data)
{
// 複雜的轉換邏輯
return data.ToUpper();
}
/// <summary>
/// 儲存資料
/// </summary>
/// <param name="data">要儲存的資料</param>
/// <returns>儲存結果</returns>
protected virtual ProcessResult SaveData(string data)
{
// 實際的資料庫操作 - 在測試中需要模擬
throw new NotImplementedException("Real database operation");
}
}
使用繼承進行部分模擬:
using Day09.Core.Models;
namespace Day09.Tests;
/// <summary>
/// DataProcessor 測試類別(部分模擬測試)
/// </summary>
public class DataProcessorTests
{
[Fact]
public void ProcessData_正常資料_應成功處理()
{
// Arrange
var processor = new TestableDataProcessor();
var validData = "test data";
// Act
var actual = processor.ProcessData(validData);
// Assert
actual.IsSuccess.Should().BeTrue();
actual.Errors.Should().BeEmpty();
}
[Fact]
public void ProcessData_空資料_應回傳失敗結果()
{
// Arrange
var processor = new DataProcessor();
var invalidData = "";
// Act
var actual = processor.ProcessData(invalidData);
// Assert
actual.IsSuccess.Should().BeFalse();
actual.Errors.Should().NotBeEmpty();
}
}
/// <summary>
/// 可測試的 DataProcessor,覆寫 SaveData 避免實際資料庫操作
/// </summary>
public class TestableDataProcessor : DataProcessor
{
protected override ProcessResult SaveData(string data)
{
// 模擬成功的儲存操作
return ProcessResult.Success();
}
}
在決定是否測試私有或 internal 成員時,我們可以從三個層次進行分析:
當你想要測試私有方法時,先問自己:「這是設計問題還是測試問題?」如果一個私有方法複雜到需要獨立測試,很可能它應該被抽取為獨立的類別或服務。好的物件導向設計告訴我們,每個類別應該有單一職責,複雜的私有方法往往表示這個類別承擔了太多責任。
考慮用重構技術如「提取類別」或「策略模式」來解決問題,而不是直接測試私有方法。這樣不只解決了測試問題,還改善了程式碼的可維護性和可讀性。
即使你決定測試私有成員,也要考慮長期的維護成本。測試私有實作細節會讓你的測試變得脆弱,每次重構都可能讓測試掛掉,即使業務邏輯沒有改變。
問問自己:「這個測試是否會成為重構的阻礙?」如果答案是肯定的,那麼可能需要重新考慮測試策略。測試應該是幫助我們更有信心地重構,而不是成為重構的障礙。
最後,評估測試帶來的實際價值。一個好的測試應該能夠:
如果測試私有方法的成本超過了它帶來的價值,那麼就應該尋找其他的測試策略,比如透過公開方法的整合測試來覆蓋相同的邏輯。
好的測試實踐:
// 好:測試公開行為
[Fact]
public void ProcessOrder_正常訂單_應回傳成功結果()
{
// 測試關注業務價值,不依賴實作細節
}
// 好:測試抽取的服務
[Fact]
public void DiscountCalculator_VIP客戶_應給予正確折扣()
{
// 邏輯獨立,容易測試和維護
}
需要避免的實踐:
// 避免:過度依賴實作細節
[Fact]
public void ProcessOrder_應按特定順序呼叫私有方法()
{
// 這種測試很脆弱,重構時容易失敗
}
// 避免:測試簡單的私有方法
[Fact]
public void GetFormattedName_應組合姓和名()
{
// 這種邏輯透過公開方法測試就足夠了
}
設計優先:好的設計自然有好的可測試性,過度需要測試私有方法通常表示設計有問題
Internal 成員測試:
InternalsVisibleTo
適合簡單情況Meziantou.MSBuild.InternalsVisibleTo
處理複雜專案私有方法測試:
替代設計模式:
決策原則:
明天我們要進入自動化測試資料生成的世界:
私有與內部成員的測試是個需要謹慎考量的議題。今天我們深入探討了這個主題的各個面向:
記住,測試的目標是建立信心和防止回歸,而不是追求 100% 的程式碼覆蓋率。聰明地選擇要測試什麼,比盲目地測試所有東西更重要。
下一篇我們要進入 AutoFixture 的世界,學習如何自動生成測試資料,讓測試撰寫更有效率。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」的第九天。明天會介紹 Day 10:AutoFixture 基礎 - 自動生成測試資料。